This simulation study aims to explore the optimal decisions for some of the most common capacity allocation challenges in airline revenue management faced by a virtual carrier, BlueSky Airline. Prescriptive models are developed by simulating the uncertainties in demand and show-up probabilities for a single-leg flight with two distinct fare classes (low- and full-fare) and incorporting realistic scenarios in the airline's reservation system. Specifically, we examine the effects of buy-up, no-show and buy-down behavior on the optimal protection level of full-fare tickets and the system overbooking limit set by BlueSky to maximize expected profit. In a baseline situation where demand is the sole source of uncertainty, the optimal protection level is found to center around the mean of full-fare demand distribution. When buy-up and no-show behaviors are considered, the optimal protection level is raised in response to harness the potential increase in full-fare demand. An optimal overbooking limit is also calculated to minimize the revenue lost by overselling a proportion of the total capacity. Lastly, when buy-down is incorporated, the scenario is complicated by multiple sources of variation and the optimum expected profit is reduced by a noticeable amount (~10%) as buy-down behavior drives down full-fare profit. Although a set combination of best protection level and overbooking limit cannot be concluded for certain due to the nature of simulation modeling, the results of this study suggest that the overbooking limit should increase as protection level increases. We propose that there is such existence of a "golden ratio" between protection level and overbooking limit that maximizes profit in every optimal cominbation generated by the model. BlueSky is advised to use the developed simulation models with updated input parameters according to their demand estimation and employ any of the top combinations suggested for a profit optimizing effect.
Since the passage of the Airline Deregulation Act in 1978, revenue management has been a key factor contributing to the long-term success of airline companies as these airlines became less restricted in updating pricing, booking level and service terms to optimize the profitability of their flights. Being able to “sell the right tickets to the right customers at the right price” through means of demand forecasting, dynamic pricing and capacity allocation simulations has become the central focus in most revenue management strategies. In the airline industry, a combination of constraints are often embedded in the ticket reservation system to serve as control mechanisms for ticket availability and in turn play a crucial role in revenue maximization. For instance, the airline may set booking limits to control the capacity of any given class of seats to be sold at any given time, thus potentially increasing the net profit by securing seats at higher price levels. Protection levels work in a similar fashion, where the airline determines a specific amount of seats to protect/reserve for a specific class or set of classes, in order to consolidate revenue from these (often more profitable) classes. The economic importance is exemplified by Delta Airlines' estimate that selling only one seat per flight at full rather than at discount rate adds over $50 million to its annual revenue[1].
Although the implementation of such control mechanisms have enormously increased profits for airline carriers, the complexity of developing and optimizing capacity allocation models can grow rapidly when multiple sources of uncertainties are involved. Demand fluctuations, for example, can bring variabilities to the reservation system. With the example of protection levels, if tickets are sold at a higher price class, the airline then increases their revenue, but if too many seats are reserved for the higher price class but its corresponding demand was insufficient, no revenue will be generated and the airline loses its opportunity to at least obtain some revenue from the discounted fare class. Additionally, variability in customer behavior such as cancellations and no-show can negatively impact flight revenue as the airline loses potential revenue to fly under capacity.
A common practice to tackle the above-mentioned uncertainties is overbooking, which involves selling at a virtual capacity above the airplane’s physical capacity to protect against potential revenue losses. However, since cancellations and no-shows are mostly random, the airline must carefully craft its ticket allocation plan and overbooking limit to achieve an optimal balance between maximizing single-flight revenue and minimizing denied boardings. In such cases where demand and customer behavior uncertainties complicate the booking system, simulation serves as a handy tool to model the possible scenarios accommodating realistic assumptions for demand distribution, cancellation probability and overbooking limits.
This study is adapted from Robert A. Shumsky’s case series, BlueSky Airlines - Single Leg Revenue Management [2], which focuses on the challenge of optimizing ticket protection level in face of uncertain demand. We will develop a base simulation model to determine the optimal booking limits and protection level that maximizes expected profit from a single-leg flight with two distinct customer segments (discount & full fare classes). The simulation model is then extended to versions that incorporate realistic customer behaviors such as buy-up, no-show and buy down, in order to further examine real-world impact of such complications on the optimal protection level and expected profit.
BlueSky airlines is currently facing a challenge in the setting of capacity limits for fare classes in order to achieve maximal profit. In this scenario, customers have the choice between buying a low-fare or full-fare ticket. BlueSky is able to profit more from full-fare tickets than low-fare tickets, but there is insufficient demand for full-fare tickets to completely fill up the aircraft.
The main parameter to be optimized is defined as
Protection Level, which is the number of seats held in reserve for full-fare tickets. According to the original case description, demand for tickets is assumed to be normally distributed. The optimal protection level can be found by testing a range of scenarios through Monte Carlo Simulation. The random seed is kept constant to ensure the reproducibility of results.
import pandas as pd
import numpy as np
import datetime
import matplotlib.pyplot as plt
import math
import scipy.stats as stats
import seaborn as sns
import plotly.graph_objects as go
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
plane_cap = 146
ff_p = 174
lf_p = 114
#Set seed, search limits and initializations
np.random.seed(777)
protect_level = range(65,146)
mean_profit_record = []
lo95 = []
hi95 = []
#Simulation
for protect_index in protect_level:
profit_record = []
for i in range(0,1000):
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
ff_sale = min(ff_demand,protect_index)
lf_sale = min(lf_demand,plane_cap-protect_index)
profit = ff_sale*ff_p + lf_sale*lf_p
profit_record.append(profit)
mean_profit = sum(profit_record)/len(profit_record)
mean_profit_record.append(mean_profit)
se = np.std(profit_record, ddof=1)/math.sqrt(1000)
t_critical = stats.t.ppf(q = 0.975, df = 999)
lo95.append(mean_profit - t_critical*se)
hi95.append(mean_profit + t_critical*se)
results_bm=pd.DataFrame({"protection level":protect_level,"mean profit":mean_profit_record,
"lower 95%":lo95,"upper 95%":hi95})
#Sort by mean profit and return top 5 protect levels
results_bm.sort_values(by="mean profit", ascending=False).head(5)
#Results visualization
plt.figure()
plt.plot(results_bm["protection level"],results_bm["mean profit"], color="red")
plt.plot(results_bm["protection level"],results_bm["lower 95%"], '--',color = "blue",linewidth=1)
plt.plot(results_bm["protection level"],results_bm["upper 95%"], '--',color = "blue",linewidth=1)
plt.title('Simulation Results with 1000 Replications')
plt.xlabel("Protection Level")
plt.ylabel("Mean Profit($)")
plt.grid(True)
This first scenario builds on top of the base model, incorporating a
Buy-Up Behavior. In a more realistic scenario, customer preferences towards product specifications are rarely completely inflexible. Most customers are inclined to reconsider alternatives in reaction to new information, or if their first choice is no longer available. The Buy-Up behavior aims to incorporate this interaction into our model.If a customer is unable to buy a low-fare ticket due to demand exceeding availability, there is now a chance for this customer to ‘Buy-Up’ to a full-fare ticket. This chance is modeled through the use of a binomial distribution, as oulined in the original case. A new simulation is run to find the profit maximizing protection level when this behavior is incorporated. Customer demand, ticket prices, plane capacity and random seed are kept constant and not changed.
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
plane_cap = 146
ff_p = 174
lf_p = 114
#Set seed, search limits and initializations
np.random.seed(777)
protect_level = range(65,146)
mean_profit_record = []
lo95 = []
hi95 = []
#Simulation
for protect_index in protect_level:
profit_record = []
for i in range(0,1000):
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
lf_sale = min(lf_demand,plane_cap-protect_index)
#Buy-up behavior adjustment
lf_sdiff = lf_demand-(plane_cap-protect_index)
if lf_sdiff > 0 : #Check if low-fare demand is greater than plane capacity
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob) #binomial distribution
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
profit = ff_sale*ff_p + lf_sale*lf_p
profit_record.append(profit)
mean_profit = sum(profit_record)/len(profit_record)
mean_profit_record.append(mean_profit)
se = np.std(profit_record, ddof=1)/math.sqrt(1000)
t_critical = stats.t.ppf(q = 0.975, df = 999)
lo95.append(mean_profit - t_critical*se)
hi95.append(mean_profit + t_critical*se)
results_s1=pd.DataFrame({"protect level":protect_level,"mean profit":mean_profit_record,"lower 95%":lo95,"upper 95%":hi95})
#Sort by mean profit and return top 10 protect levels
results_s1.sort_values(by="mean profit", ascending=False).head(10)
plt.figure()
plt.plot(results_s1["protect level"],results_s1["mean profit"], color="red")
plt.plot(results_s1["protect level"],results_s1["lower 95%"], '--',color = "blue",linewidth=1)
plt.plot(results_s1["protect level"],results_s1["upper 95%"], '--',color = "blue",linewidth=1)
plt.title('Simulation Results with 1000 Replications')
plt.xlabel("Protection Level")
plt.ylabel("Mean Profit($)")
plt.grid(True)
The optimal protection level went up from 92 to 106 after adding the buy-up behavior. It makes sense because the buy-up behavior potentially pushed up the demand for full-fare tickets, which means that we need a higher protection level to reserve the the seats for more passengers willing to pay full fare. The mean profit for the optimal decisions also went up as more passengers are willing to pay full-fare.
The second scenario builds on top of scenario 1, further incorporating a
No-Showprobability. Rarely do all customers who have purchased a ticket show up on the day of travel. This could be due to emergencies, schedule changes or a host of other possibilities. However, every seat flown without a passenger is a lost opportunity for airlines to increase revenue. Therefore, there is a need for the model to capture this behavior in order to produce an accurate profit maximizing protection level.In this scenario, we assume that low-fare customers have a 100% chance of showing up because low-fare tickets are non-refundable. There is a chance for full-fare customers to not show up, and would receive a full refund for their ticket in that case. If the model decides to overbook the flight, there is a chance that the customers who end up showing up would exceed plane capacity. In that case, customers who are unable to board due to overbooking would receive monetary compensation, which is taken into consideration in profit calculation as a loss for that flight. The degree of overbooking is represented by the
Virtual Capacity, which is the combined number of tickets sold in the first place. The overbooked amount can be calculated by subtracting the actual plane capacity from virtual capacity. Again, parameters from previous scenarios are kept constant and remain unchanged.
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
ffshowup_prob = 0.92
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
#Simulation
for capacity_index in capacity_level:
for protect_index in range(65,capacity_index+1):
profit_record = []
db_record = []
for i in range(0,1000):
profit = 0
db = 0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
lf_sale = min(lf_demand,capacity_index-protect_index)
#Buy-up behavior adjustment
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p #full-fare tickets are refundable
profit_record.append(profit)
db_record.append(db)
capacity_level_record.append(capacity_index)
overbook_record.append(capacity_index - plane_cap)
protect_level_record.append(protect_index)
mean_profit = sum(profit_record)/len(profit_record)
mean_profit_record.append(mean_profit)
avg_db = sum(db_record)/len(db_record)
avgdb_record.append(avg_db)
se = np.std(profit_record, ddof=1)/math.sqrt(1000)
t_critical = stats.t.ppf(q = 0.975, df = 999)
lo95.append(mean_profit - t_critical*se)
hi95.append(mean_profit + t_critical*se)
results_s2 = pd.DataFrame({"virtual capacity":capacity_level_record, "overbooking limit": overbook_record,
"protect level":protect_level_record, "mean profit":mean_profit_record, "lower 95%":lo95,
"upper 95%":hi95,"avg denied boarding":avgdb_record})
#Sort by mean profit and return top 10 protect levels
results_s2.sort_values(by="mean profit", ascending=False).head(10)
The optimal protection level slightly went up from 106 to 108 after adding the over-booking. This is caused by the 8% possibility of the full fare passengers not showing up. A high virtual capacity (thus more overbooking) and a high protection level seem to be mostly favoured in the top 10 optimal combinations, meaning that we sell a large number of full-fare tickets to ensure enough full-fare passengers to show up to obtain a higher profit. However, this policy did not reverse the impact of no-shows, and the mean profit for the optimal decision still went down.
The third and final scenario builds on top of scenario 2, adding a
Buy-Downbehavior as well. In scenario 1, we mentioned that customers may sometimes go for a (more expensive) alternative when their first choice is no longer available; the opposite of this is true as well. Therefore, there is now a chance for full-fare customers to ‘buy-down’ to low-fare tickets. Similar to the ‘buy-up’ behavior, the probability of buying down is also modeled through the use of a binomial distribution, according to the original case description.Since low-fare tickets are supposed to be representative of a cheaper inflexible ticket class and full-fare tickets are representative of a more expensive ticket class with a high degree of flexibility, it makes sense to have low-fare tickets sell out first before full-fare tickets. Therefore, we assume that the ‘buy-down’ behavior happens before the ‘buy-up’ behavior, as low-fare customers would only pay more to buy a full-fare ticket if there are no more low-fare tickets. Parameters from previous scenarios are also kept constant and remain unchanged.
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
buydown_prob = 0.6
ffshowup_prob = 0.92
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
#Simulation
for capacity_index in capacity_level:
for protect_index in range(65,capacity_index+1):
profit_record = []
db_record = []
for i in range(0,1000):
profit = 0
db=0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
capacity_level_record.append(capacity_index)
overbook_record.append(capacity_index - plane_cap)
protect_level_record.append(protect_index)
mean_profit = sum(profit_record)/len(profit_record)
mean_profit_record.append(mean_profit)
avg_db = sum(db_record)/len(db_record)
avgdb_record.append(avg_db)
se = np.std(profit_record, ddof=1)/math.sqrt(1000)
t_critical = stats.t.ppf(q = 0.975, df = 999)
lo95.append(mean_profit - t_critical*se)
hi95.append(mean_profit + t_critical*se)
results_s3 = pd.DataFrame({"virtual capacity":capacity_level_record,"overbooking limit": overbook_record,
"protect level":protect_level_record,"mean profit":mean_profit_record,
"lower 95%":lo95,"upper 95%":hi95,"avg denied boarding":avgdb_record})
#Sort by mean profit and return top 10 protect levels
results_s3.sort_values(by="mean profit", ascending=False).head(10)
The profit-maximizing protection level went up significantly from 108 to 129 after adding buy-down behavior. The buy-down behavior is essentially a decrease on the demand of full-fare tickets, and it makes sense to expect fewer full-fare passengers. The rise is added to prevent buy-down behavior. The effectiveness of this policy is low and we will lose a large amount of profits with thw buy-down behavior. This also implies how competitive the low fare airline market is.
The simulation model can be verified by eliminating all sources of variation so that it is reasonably straightforward to calculate the results. A final model with these parameters would be essentially equivalent to the base-level model due. If we manage to get the same results as the first model, we can be certain that the logic coded into the full model is correct.
To achieve this result, we first changed the standard deviation of demand to 0 so that passenger demand for tickets is always constant. The next step is to set the probability of ‘buy-up’ and ‘buy-down’ to close to 0 to mostly eliminate buy-up/down behavior. The last step is to set the probability of customers showing up close to 1. Probabilities are set close to 0 and 1 instead of the numbers itself in order to retain the feature for verification purposes.
We assume there is no variation with the demand, with
Chance of buy-up and buy-down behavior is close to 0.
Chance of full-fare customers showing up is close to 1.
#Set parameters for verification
ff_mean = 92
ff_sd = 0
lf_mean = 80
lf_sd = 0
buyup_prob = 0.000001
buydown_prob = 0.000001
ffshowup_prob = 0.99999
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
#Simulation
for capacity_index in capacity_level:
for protect_index in range(65,capacity_index+1):
profit_record = []
db_record = []
for i in range(0,1000):
profit = 0
db=0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
capacity_level_record.append(capacity_index)
overbook_record.append(capacity_index - plane_cap)
protect_level_record.append(protect_index)
mean_profit = sum(profit_record)/len(profit_record)
mean_profit_record.append(mean_profit)
avg_db = sum(db_record)/len(db_record)
avgdb_record.append(avg_db)
se = np.std(profit_record, ddof=1)/math.sqrt(1000)
t_critical = stats.t.ppf(q = 0.975, df = 999)
lo95.append(mean_profit - t_critical*se)
hi95.append(mean_profit + t_critical*se)
results_v = pd.DataFrame({"virtual capacity":capacity_level_record,"overbooking limit": overbook_record,
"protect level":protect_level_record, "mean profit":mean_profit_record,
"lower 95%":lo95,"upper 95%":hi95, "avg denied boarding":avgdb_record})
#Sort by mean profit and return top 10 protect levels
results_v.sort_values(by="mean profit", ascending=False).head(10)
When the demands are fixed without variation, we can see that the profit is constant and maximized whenever the full-fare sale is at 92 and low-fare sale at 54 (146-92). There is no denied boarding. It verifies the logic of our models.
In the full model, the final profit figure is heavily reliant on two decision variables:
Protection LevelandVirtual Capacity. As the two variables can only be integers, we can visualize the effect on profit by looking at an exhaustive set of combinations in a pre-defined range. Note that this visualization is not an absolute representation of the marginal change on profit per change in each decision variable, as the results are obtained from simulation. Changing the random seed would result in different profit figures, so the graph below can only be used as a guideline. However, for the purposes of this case study, enough repetitions have been done so that the results are representative of an overall outcome. Therefore, we are still able to gain valuable insight into the effect of the decision variables on profit.
results_map=results_s3[["virtual capacity","protect level","mean profit"]]
map_df = results_map.pivot(index='protect level', columns="virtual capacity", values="mean profit")
plt.figure(figsize = (16,10))
ax = sns.heatmap(map_df, linewidth=0)
ax.set_title('Decisions Exploration')
plt.xlabel = "Virtual Capacity"
plt.ylabel = "Protection Level"
#plt.grid(True)
plt.show()
From the plot above, we can see that the highest likelihood of getting a high profit is clustered in an even band from top left corner to the bottom right corner (where the color is lightest). The likelihood of getting a higher profit decreases evenly further away from the band.
Sensitivity analysis can be done on the final model by varying each individual parameter while holding others constant to observe the effect on profit. We also assume that an airline in real life would be sensitive towards the amount of people who were denied boarding as it is extremely costly and diminishes brand reputation. Therefore, the effect of parameter changes on the average number of customers who were denied boarding are investigated as well. To hold decision variables constant, we chose to perform sensitivity analysis on the top-5 combinations of
Protection LevelandVirtual Capacityin terms of generating the maximal profit.The threshold level was set to +/- 10% and 20% to simulate real life conditions. Using both 10% & 20% would also have the added benefit of revealing any linear effects of parameters on profit and number of passengers denied boarding. One caveat to the parameter selection is for the ‘show-up’ probability, or 1 – the no-show probability. Since the original probability of a full-fare customer showing up is 92%, we cannot increase it by 10% as it will exceed 100%. Therefore, this parameter is capped at 100% for the purposes of sensitivity analysis. Again, it is worth mentioning that these results are meant to be used as a guideline and not as absolute marginal effects, as there will always be fluctuations in the result for different random seeds.
#Set unchanged parameters
plane_cap = 146
ff_p = 174
lf_p = 114
#Sensitivity analysis preparation
ff_mean = 92
lf_mean = 80
ff_sd = 30
lf_sd = 25
buyup_prob = 0.3
buydown_prob = 0.6
ffshowup_prob = 0.92
penalty_unit = 180
sensi_lst = [92, 80, 30, 25, 0.3, 0.6, 0.92, 180]
#Assign changed values to parameters list
def assign(sensi_lst):
ff_mean = sensi_lst[0]
lf_mean = sensi_lst[1]
ff_sd = sensi_lst[2]
lf_sd = sensi_lst[3]
buyup_prob = sensi_lst[4]
buydown_prob = sensi_lst[5]
ffshowup_prob = sensi_lst[6]
penalty_unit = sensi_lst[7]
#Reset global parameters list
def reset():
global sensi_lst
sensi_lst = [92, 30, 80, 25, 0.3, 0.6, 0.92, 180]
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
results_record = pd.DataFrame()
#Top5 pairs from Scenario 3
optimal_virtual_capacity = [233,197,182,164,216]
optimal_protect_level = [129,96,81,67,121]
#-10%
for q in range(0,5):
capacity_index = optimal_virtual_capacity[q]
protect_index = optimal_protect_level[q]
for lst_index in range(0,8):
sensi_lst[lst_index] = sensi_lst[lst_index]*0.9
assign(sensi_lst)
profit_record = []
db_record=[]
for i in range(0,1000):
profit = 0
db = 0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
mean_profit = sum(profit_record)/len(profit_record)
avg_db = sum(db_record)/len(db_record)
results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
"mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
results_record = pd.concat([results_record,results],axis=0)
reset()
#+10%
for q in range(0,5):
capacity_index = optimal_virtual_capacity[q]
protect_index = optimal_protect_level[q]
for lst_index in range(0,8):
#+10% change is not applicable to ffshowup_prob, as its original value is 0.92.
#So we set ffshowup_prob = 1.0 when applying +10% change.
if lst_index != 6:
sensi_lst[lst_index] = sensi_lst[lst_index]*1.1
else:
sensi_lst[lst_index] = 1.0
assign(sensi_lst)
profit_record = []
db_record=[]
for i in range(0,1000):
profit = 0
db = 0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
mean_profit = sum(profit_record)/len(profit_record)
avg_db = sum(db_record)/len(db_record)
results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
"mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
results_record = pd.concat([results_record,results],axis=0)
reset()
#-20%
for q in range(0,5):
capacity_index = optimal_virtual_capacity[q]
protect_index = optimal_protect_level[q]
for lst_index in range(0,8):
sensi_lst[lst_index] = sensi_lst[lst_index]*0.8
assign(sensi_lst)
profit_record = []
db_record=[]
for i in range(0,1000):
profit = 0
db = 0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
mean_profit = sum(profit_record)/len(profit_record)
avg_db = sum(db_record)/len(db_record)
results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
"mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
results_record = pd.concat([results_record,results],axis=0)
reset()
#+20%
for q in range(0,5):
capacity_index = optimal_virtual_capacity[q]
protect_index = optimal_protect_level[q]
for lst_index in range(0,8):
#+20% change is not applicable to ffshowup_prob, as its original value is 0.92.
#So we set ffshowup_prob = 1.0 when applying +20% change.
if lst_index != 6:
sensi_lst[lst_index] = sensi_lst[lst_index]*1.2
else:
sensi_lst[lst_index] = 1.0
assign(sensi_lst)
profit_record = []
db_record=[]
for i in range(0,1000):
profit = 0
db = 0
ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
#Buy-down behavior adjustment
ff_buydown = np.random.binomial(ff_demand,buydown_prob)
ff_demand -= ff_buydown
lf_demand += ff_buydown
#Buy-up behavior adjustment
lf_sale = min(lf_demand,capacity_index-protect_index)
lf_sdiff = lf_demand-(capacity_index-protect_index)
if lf_sdiff > 0 :
lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
ff_demand += lf_buyup
ff_sale = min(ff_demand,protect_index)
#Overbooking penalty adjustment
ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
if ff_showup + lf_sale > plane_cap:
db=-(plane_cap - ff_showup - lf_sale)
penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
profit += penalty
profit += ff_showup*ff_p + lf_sale*lf_p
profit_record.append(profit)
db_record.append(db)
mean_profit = sum(profit_record)/len(profit_record)
avg_db = sum(db_record)/len(db_record)
results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
"mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
results_record = pd.concat([results_record,results],axis=0)
reset()
#Adding Change column to indicate which parameter is changed each round
change1 = ["ff_mean-10%","lf_mean-10%","ff_sd-10%","lf_sd-10%","buyup_prob-10%","buydown_prob-10%",
"ffshowup_prob-10%","penalty_unit-10%"]
change2 = ["ff_mean+10%","lf_mean+10%","ff_sd+10%","lf_sd+10%","buyup_prob+10%","buydown_prob+10%",
"ffshowup_prob=1.0","penalty_unit+10%"]
change3 = ["ff_mean-20%","lf_mean-20%","ff_sd-20%","lf_sd-20%","buyup_prob-20%","buydown_prob-20%",
"ffshowup_prob-20%","penalty_unit-20%"]
change4 = ["ff_mean+20%","lf_mean+20%","ff_sd+20%","lf_sd+20%","buyup_prob+20%","buydown_prob+20%",
"ffshowup_prob=1.0","penalty_unit+20%"]
results_record["Change"] = change1*5+change2*5+change3*5+change4*5
results_record = results_record[['Change','virtual capacity', 'protect level', 'mean profit', 'avg denied boarding' ]]
#Top5 results from Scenario 3
top5 = results_s3.sort_values(by="mean profit", ascending=False).head(5)
top5["mean profit %change"] = 100
top5["avg denied boarding %change"] = 100
#Record original profit and average denied boarding for %change calculation
original_profit_lst = (list(top5["mean profit"][0:1])*8 + list(top5["mean profit"][1:2])*8
+ list(top5["mean profit"][2:3])*8 + list(top5["mean profit"][3:4])*8
+ list(top5["mean profit"][4:5])*8)*4
original_avgdb_lst = (list(top5["avg denied boarding"][0:1])*8 + list(top5["avg denied boarding"][1:2])*8
+ list(top5["avg denied boarding"][2:3])*8 + list(top5["avg denied boarding"][3:4])*8
+ list(top5["avg denied boarding"][4:5])*8)*4
#Calculate %change for each round of change
results_record["mean profit %change"] = (results_record["mean profit"]/np.array(original_profit_lst) - 1)*100
results_record["avg denied boarding %change"] = (results_record["avg denied boarding"]/np.array(original_avgdb_lst) - 1)*100
results_record
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Mean Profit: Virtual Capacity = 233, Protection Level = 129",
"Mean Profit: Virtual Capacity = 197, Protection Level = 96",
"Mean Profit: Virtual Capacity = 182, Protection Level = 81",
"Mean Profit: Virtual Capacity = 164, Protection Level = 67",
"Mean Profit: Virtual Capacity = 216, Protection Level = 121"]
for combo in range(0,5):
fig = go.Figure()
j = -1
fig.add_trace(go.Scatterpolar(
r= list(top5['mean profit'][combo:combo+1])*8,
theta=categories,
fill='toself',
name=change[4],
line_color = colors[4]
))
for i in range(8+combo*8,161,40):
j += 1
fig.add_trace(go.Scatterpolar(
r= results_record['mean profit'][i-8:i],
theta=categories,
fill='toself',
name=change[int((i-8*(combo+1))/40)],
line_color = colors[j]
))
fig.update_layout(
title = titles[combo],
polar=dict(
radialaxis=dict(
visible=True,
range=[17500, 18000]
)),
showlegend=True
)
fig.show()
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Mean Profit %Change: Virtual Capacity = 233, Protection Level = 129",
"Mean Profit %Change: Virtual Capacity = 197, Protection Level = 96",
"Mean Profit %Change: Virtual Capacity = 182, Protection Level = 81",
"Mean Profit %Change: Virtual Capacity = 164, Protection Level = 67",
"Mean Profit %Change: Virtual Capacity = 216, Protection Level = 121"]
for combo in range(0,5):
fig = go.Figure()
j = -1
fig.add_trace(go.Scatterpolar(
r= list(top5['mean profit %change'][combo:combo+1])*8,
theta=categories,
fill='toself',
name=change[4],
line_color = colors[4]
))
for i in range(8+combo*8,161,40):
j += 1
fig.add_trace(go.Scatterpolar(
r= results_record['mean profit %change'][i-8:i],
theta=categories,
fill='toself',
name=change[int((i-8*(combo+1))/40)],
line_color = colors[j]
))
fig.update_layout(
title = titles[combo],
polar=dict(
radialaxis=dict(
visible=True,
range=[-2, 0.5]
)),
showlegend=True
)
fig.show()
No matter the parameters change towards the favorable side or unfavorable side, the mean profit will almost decline (0.17%~2.06%) for all top 5 pairs of protection level and virtual capacity, except the third pair (Virtual Capacity = 182, Protection Level = 81), whose mean profit increase 0.08% when the low-fare standard deviation parameter decreases by 20%.
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Avgerage # of Denied Boarding: Virtual Capacity = 233, Protection Level = 129",
"Avgerage # of Denied Boarding: Virtual Capacity = 197, Protection Level = 96",
"Avgerage # of Denied Boarding: Virtual Capacity = 182, Protection Level = 81",
"Avgerage # of Denied Boarding: Virtual Capacity = 164, Protection Level = 67",
"Avgerage # of Denied Boarding: Virtual Capacity = 216, Protection Level = 121"]
for combo in range(0,5):
fig = go.Figure()
j = -1
fig.add_trace(go.Scatterpolar(
r= list(top5['avg denied boarding'][combo:combo+1])*8,
theta=categories,
fill='toself',
name=change[4],
line_color = colors[4]
))
for i in range(8+combo*8,161,40):
j += 1
fig.add_trace(go.Scatterpolar(
r= results_record['avg denied boarding'][i-8:i],
theta=categories,
fill='toself',
name=change[int((i-8*(combo+1))/40)],
line_color = colors[j]
))
fig.update_layout(
title = titles[combo],
polar=dict(
radialaxis=dict(
visible=True,
range=[0, 8]
)),
showlegend=True
)
fig.show()
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Average Denied Boarding %change: Virtual Capacity = 233, Protection Level = 129",
"Average Denied Boarding %change: Virtual Capacity = 197, Protection Level = 96",
"Average Denied Boarding %change: Virtual Capacity = 182, Protection Level = 81",
"Average Denied Boarding %change: Virtual Capacity = 164, Protection Level = 67",
"Average Denied Boarding %change: Virtual Capacity = 216, Protection Level = 121"]
for combo in range(0,5):
fig = go.Figure()
j = -1
fig.add_trace(go.Scatterpolar(
r= list(top5['avg denied boarding %change'][combo:combo+1])*8,
theta=categories,
fill='toself',
name=change[4],
line_color = colors[4]
))
for i in range(8+combo*8,161,40):
j += 1
fig.add_trace(go.Scatterpolar(
r= results_record['avg denied boarding %change'][i-8:i],
theta=categories,
fill='toself',
name=change[int((i-8*(combo+1))/40)],
line_color = colors[j]
))
fig.update_layout(
title = titles[combo],
polar=dict(
radialaxis=dict(
visible=True,
range=[-20, 15]
)),
showlegend=True
)
fig.show()
The average number of denied boarding change in a more complex way, regarding different pair of virtual capacity and protection level. The percentage of change ranges from -17.76% to 10.59%.
After using multiple models to simulate profit under a wide range of scenarios, we found that there is a complex relationship between the decision variables Protection Level, Virtual Capacity, Overbooking Limit and profit.
For the base model where the only significant variation is passenger demand, the optimal protection level appears to be around the 85-95 range. Logically, BlueSky should be able to achieve maximal profit for a given demand when the protection level is set just high enough to capture all of the full-fare demand. Setting protection level too high would result in empty seats on a plane and drive down profit due to insignificant full-fare demand. Therefore, this range is a representation of the combined means of the demand distribution, as it is the most common occurrence.
In scenario 1, the buy-up behavior gives low-fare customers an opportunity to change into full-fare customers. Therefore, it makes sense to raise the protection level and reserve more capacity for full-fare tickets in order to capture this increase in demand. This resulted in a higher new optimal protection level, as well as increased mean profit compared to the base model. Thus, we can conclude that the buy-up behavior has a net positive effect on profit and should be encouraged.
The results become harder to interpret after scenario 1, as there are now multiple sources of variation impacting profit. Looking at the results from scenario 2, we can see that mean profit for the optimal decision variables stayed roughly the same when compared to scenario 1. This means that under the base parameters of show-up probability and denied boarding penalty (given in the case study), the positive effects of overbooking and negative effects of paying out a denied boarding penalty is able to balance out each other, resulting in a net zero effect. Therefore, as long as BlueSky is able to use this model to select an optimal combination of decision variables, they should still be able to earn the same amount of profit in scenario 2.
Yet in scenario 3, when we incorporate the ‘buy-down’ behavior, profits were reduced by a large amount (approx. 10-12% on average) even if the optimal combination of decision variables were chosen. The ‘buy-down’ behavior removed a large amount of full-fare demand, driving down profits gained from the ‘buy-up’ behavior. However, the average number of people who were denied boarding are now less varied. This increase in stability is likely due to the model now having to manage multiple sources of variation, narrowing down the range of possible combinations of decision variables.
The relationship between the decision variables becomes clearer when we perform exhaustive exploration of possible combinations. Looking at the decisions exploration graph, we see that the area of highest profit (lightest area) forms an approximately linear trend-line with a negative slope. The interpretation is that for the final model with complete features, virtual capacity should be increased as protection level increases. This means that BlueSky should increase the overbooking amount as they reserve more seats for full-fare in order to compensate for the extra penalty costs they would be paying. There is likely a "golden ratio" between protection level and virtual capacity for every possible optimal combination. However, since the results are obtained from simulation, there is still a lot of uncertainty and noise in the results which can visualized by the many different shades of color in the graph. This ratio would also change for different parameters, so the optimal solution would still be to undergo simulations.
The sensitivity analysis also provides insight on the estimated effect of parameter changes for the top profit maximizing decision variable combinations. Visualizing the effects of the top combinations is enough because BlueSky should always be wanting to maximize profit and be only concerned about the sensitivity of profit under this situation. We assume that the decision variables (Protection Level & Virtual Capacity) are limits that BlueSky must set and leave it unchanged in the short-term. Therefore, it would be useful to understand the effects of profit from fluctuations in model parameters (i.e. low-fare demand). Observing the results, we see that profit always decreases when parameters are changed, even in positive ways such as increased demand as the decision variables are not optimized anymore under this new environment. Furthermore, none of the relationships are linear in nature, and do not exhibit any sort of consistent pattern. However, we can still conclude that profit is not very sensitive to changes in a single model parameter as even a 20% drop in demand mean only resulted in profit decrease of < 2%. The number of people who are denied boarding is also fairly insensitive to parameter changes, with only a fluctuation of 1-2 people in most cases.
Overall, BlueSky’s optimization challenge can be effectively handled through the use of Monte Carlo simulations. The above models are able to find the optimal profit maximizing decision variable in a range of scenarios if given the correct input variables. Even with the optimal decision variables, key outcome measures such as profit and amount of passengers denied boarding will still decrease as model parameters are changed. However, the model is quite robust, and profit will still remain relatively stable as seen from the sensitivity analysis.
We recommend BlueSky to encourage ‘buy-up’ behavior as it has a net positive effect on profit. ‘Buy-down’ behavior should be discouraged as it has a negative effect on profit. Overbooking strategies should be implemented to counter losses from no-shows according to results from the model. Parameters generated from simulation can only be guaranteed as optimal if model parameters stay unchanged. BlueSky should run the simulation again once market conditions differ (i.e. change in low-fare demand) and change the protection level and virtual capacity to new the outputs to maintain maximal profit.
[1] Bertsimas, D., & de Boer, S. (2005). Simulation-based booking limits for airline revenue management. Operations Research, 53(1), 90-106. https://doi.org/10.1287/opre.1040.0164
[2] Shumsky, R. A. (2009). Case series-BlueSky airlines: Single-leg revenue management. Transactions on Education, 9(3), 140-144. https://doi.org/10.1287/ited.1090.0033cs1